Injection Flaws
Injection is what happens when data the user controls gets treated as code. It is one of the oldest flaws on the web and still one of the most common. This lab teaches you to spot it in the code, fix it, and test for it.
1 The Hook
Harbourview Credit Union, a fictional NZ lender, ran a member portal. The login screen was ordinary: a username field, a password field, a sign-in button. A tester on the team was bored one afternoon and typed something odd into the username box instead of a name. Where a name should go, they typed an apostrophe followed by a fragment of database logic.
The portal logged them straight in. As the first member in the database. No password needed.
What had happened is that the login code built its database query by gluing the typed username directly into a string of SQL. The apostrophe closed the username early, and the fragment after it rewrote the meaning of the query so that the password check always came out true. The code did exactly what it was told — it just could not tell the difference between the member’s data and a member’s instructions.
This is SQL injection, and the same flaw in production would have let an attacker read every member’s balance, change account details, or delete the lot. It was not a clever exploit. It was one of the first things any security tester tries, and the code had no defence against it.
Here is the lesson: the tester found it in seconds by treating an input field as a way to send instructions, not just data. That is the mindset this lab builds. Injection hides in code that mixes user input with commands, and you find it by reading for that mix.
2 The Rule
Injection happens when user input is allowed to change the structure of a command — SQL, a shell command, an LDAP filter — instead of being treated as pure data. The defence is always the same: keep data and code apart. If you ever see user input concatenated into a query string, you have found an injection flaw, no matter how the input was “cleaned” first.
3 The Analogy
A drive-through order shouted through the same window the cook takes instructions from.
Imagine a takeaway in Lower Hutt where the customer and the kitchen manager both speak to the cook through one window, in the same voice, with no way to tell them apart. A customer orders “a burger” and all is well. Then a customer orders “a burger, and also give me everything in the till, and lock the back door.” The cook cannot tell the order from the management instruction, so the cook does all of it. That is injection: the customer’s words got mixed in with the trusted instructions, and the system obeyed the lot.
The fix is a second window. Orders come through the order window and can only ever be food choices — never instructions to the kitchen. A parameterised query is that second window: user input arrives as a labelled value that can never become part of the command.
4 What Injection Actually Is
An injection flaw exists wherever a program builds a command out of text and lets untrusted input become part of that text. The classic case is SQL, but the pattern repeats across the stack. OWASP groups the whole family under A03: Injection in its Top 10.
The shape is always the same three ingredients:
- An interpreter that reads a command — a SQL database, a shell, an LDAP directory, an XML parser, a NoSQL engine.
- A command built as text — the program assembles the instruction as a string at runtime.
- Untrusted input mixed in — something the user controls is concatenated into that string.
When all three are present, a user can craft input that changes what the command does. Remove any one of them and the flaw is gone — which is why the fix is structural, not cosmetic. You do not “clean” injection away; you stop building commands out of untrusted text.
5 Spotting SQL Injection in Code
SQL injection is the most common and most damaging member of the family. Here is the Harbourview login flaw, written out so you can see the exact line that breaks it:
username = request.form['username']
password = request.form['password']
query = "SELECT * FROM members WHERE username = '" + username + "' AND password = '" + password + "'"
result = db.execute(query)
if result:
login_user(result[0])
The fatal line is the one that builds query by concatenating username and password straight into the SQL. If someone types ' OR '1'='1 into the username, the query becomes ... WHERE username = '' OR '1'='1' AND ... — a condition that is always true, so the database returns the first member and the login succeeds. The same trick can read other tables, dump data, or with a stacked statement, delete records.
The tell-tale signs you read for:
- String concatenation or interpolation into a query — the
+, the f-string, the template literal that drops a variable into SQL. - Input taken from a request just above, with no parameter binding between it and the query.
- A reassuring but useless “clean” step — stripping spaces or escaping a few characters does not make concatenation safe.
6 Command and Other Injection
Once you can see the pattern in SQL, you can see it everywhere. The same flaw with a different interpreter gives a different attack.
Command (OS) injection
When a program builds a shell command from input. A fictional IRD batch tool that runs os.system("gpg --decrypt " + filename) with a user-supplied filename lets an attacker pass doc.gpg; rm -rf /data and run their own commands. The interpreter is the shell; the fix is to call the program directly with an argument list, never a shell string.
NoSQL injection
Document databases are not immune. If a login takes a JSON body and passes it straight into a query object, an attacker can send an operator instead of a value — a comparison that always matches — and bypass the password check. Same flaw, different interpreter.
LDAP and others
An LDAP filter built by concatenating a username can be broken with filter syntax. XML, XPath, and even some logging frameworks have the same shape. The reading habit transfers directly: find the command built as text, find the input mixed in.
7 The Fix: Parameterised Queries
The fix for SQL injection is the parameterised query, also called a prepared statement. Instead of building one string, you write the command with placeholders and pass the user input separately as values. The database treats those values as data only — they can never become part of the command structure.
Here is the Harbourview login, fixed:
username = request.form['username']
password = request.form['password']
query = "SELECT * FROM members WHERE username = ? AND password_hash = ?"
result = db.execute(query, (username, hash_password(password)))
if result:
login_user(result[0])
Two things changed. The query now uses ? placeholders, and the actual values are handed to db.execute as a separate tuple — so ' OR '1'='1 typed into the username is searched for as a literal username and simply not found. The login fails, as it should. (The example also hashes the password rather than comparing it in plain text, which is a separate but related good practice.)
The rules that make a fix correct:
- Parameterise every value — one concatenated value left behind is a hole.
- Do not parameterise then re-concatenate — some developers parameterise the easy fields and glue in a table or column name; that is still injectable. Use a strict allow-list for anything that cannot be a parameter.
- Prefer the database’s native binding over building strings yourself, and prefer a vetted query builder or ORM that parameterises by default.
8 Building Injection Test Cases
Finding the flaw is half the job. As a tester you also write the cases that prove the fix holds and that catch a regression later. An injection test case names the input, the endpoint, and what must not happen.
Vulnerability: SQL injection (OWASP A03)
Endpoint: POST /portal/login — username, password fields
Input: username = ' OR '1'='1' --
Expected result: Login is REJECTED. No member session is created.
Response is the normal "invalid credentials" message.
Must NOT happen: Successful login; SQL error returned to the client;
any difference in response time or content that reveals the query.
Evidence: Request/response capture; server log showing parameterised query.
Traceability: Risk R-11 (authentication bypass via injection).
Result: [Pass / Fail]
A good injection test suite covers more than one payload: a tautology (' OR '1'='1), a comment terminator (--), a stacked statement attempt, and a UNION attempt. It also includes a negative-control case — a legitimate username with an apostrophe in it, like O'Brien, which must still log in normally. That last one proves the fix did not break valid data, which is the difference between a security test and a blunt input ban.
9 Common Mistakes
🚫 Believing input sanitisation alone stops injection
Why it happens: Stripping quotes or escaping a few characters feels like a fix and often blocks the obvious payload.
The fix: Blocklists are always incomplete — encodings, alternate syntax, and database-specific tricks get past them. The structural fix is parameterised queries, which make injection impossible regardless of input. Sanitisation is at best a second layer, never the primary defence.
🚫 Only testing the login box
Why it happens: The login is the famous example, so testing stops there.
The fix: Injection lives anywhere input reaches an interpreter — search fields, sort and filter parameters, hidden form fields, HTTP headers, and API JSON bodies. Test every input that ends up in a query, not just the visible ones.
🚫 Treating a missing error message as a pass
Why it happens: No visible SQL error looks like the attack failed.
The fix: Blind injection returns no error at all — the attacker infers data from timing or true/false differences in the response. A clean-looking response is not proof of safety. Test for behavioural differences, not just error text.
🚫 Forgetting the negative-control test
Why it happens: Once the attack is blocked the job feels done.
The fix: A fix that simply bans apostrophes breaks legitimate names like O’Connor and Te Kūiti addresses. Always include a valid-but-tricky input that must still work, so you prove the system is secure and still correct.
10 Now You Try
Three graded exercises: spot the vulnerability, fix the code, then design the tests. Write your answer, run it for AI feedback, then compare to the model answer. Every snippet here is a safe teaching example — you are identifying and repairing flaws, never attacking a real system.
Read the RealMe-style login handler below for a fictional NZ council services portal. Identify the injection vulnerability, name the exact line that causes it, and explain in one or two sentences how an attacker would abuse it.
def login(request):
user = request.POST['user']
pwd = request.POST['pwd']
sql = f"SELECT id, role FROM accounts WHERE user='{user}' AND pwd='{pwd}'"
row = db.query(sql)
if row:
return start_session(row[0])
return error("Invalid login")
Name the vulnerability, the line, and the exploit:
Show model answer
Vulnerability: SQL injection (OWASP A03).
The vulnerable line: sql = f"SELECT id, role FROM accounts WHERE user='{user}' AND pwd='{pwd}'" — the f-string interpolates user-controlled `user` and `pwd` straight into the SQL, so the input can change the query's structure.
How an attacker abuses it: typing ' OR '1'='1' -- into the user field makes the WHERE clause always true and the `--` comments out the password check, so db.query returns the first account (often an admin) and start_session logs the attacker in with no valid credentials. A stacked or UNION payload could also read or dump other tables.
The single root cause is that data (user input) is allowed to become code (SQL structure). The fix is parameterisation, covered in Exercise 2.
Rewrite the vulnerable function below so the injection flaw is closed. It is a fictional IRD-number lookup that searches a customer database. Use a parameterised query. Keep the function doing the same job — just make it safe.
def find_customer(ird_number):
q = "SELECT name, address FROM customers WHERE ird = '" + ird_number + "'"
return db.execute(q)
Write the fixed function:
Show model answer
def find_customer(ird_number):
q = "SELECT name, address FROM customers WHERE ird = ?"
return db.execute(q, (ird_number,))
What makes this correct: the value is no longer concatenated into the SQL. The `?` placeholder marks where a value goes, and ird_number is passed separately as a parameter, so the database treats it as pure data — an injection payload like ' OR '1'='1 is searched for as a literal IRD string and simply not found.
Also acceptable: a named placeholder (e.g. WHERE ird = :ird with {"ird": ird_number}), or an ORM call that parameterises under the hood. A defensive bonus is validating that ird_number matches the expected IRD format (digits only) before the query — but validation is a second layer, not a substitute for parameterisation. A fix that still concatenates anywhere, or that only escapes quotes, is not correct.
Design 4 security test cases for the login endpoint of a fictional Kiwibank-style internet banking portal to prove it is not vulnerable to injection. Each case needs: an ID, the input/payload, the expected result, and what must NOT happen. Include at least one negative-control case (a legitimate input that must still work).
Show model answer
SEC-INJ-01 | Payload: username = ' OR '1'='1' -- | Expected: login rejected, normal "invalid credentials" message, no session created | Must NOT happen: successful login; any account returned SEC-INJ-02 | Payload: username = admin'-- | Expected: login rejected | Must NOT happen: comment terminator bypasses the password check and logs in as admin SEC-INJ-03 | Payload: username = x' UNION SELECT card_number,1 FROM cards -- | Expected: login rejected, no data returned in the response | Must NOT happen: any card or account data appears in the response body or error SEC-INJ-04 (negative control) | Input: username = O'Brien with the correct password | Expected: login SUCCEEDS normally | Must NOT happen: the apostrophe is rejected or the login fails — proves the fix did not ban legitimate data Strong suites also: capture request/response as evidence, add a timing/blind-injection check (a payload that would sleep the DB must not change response time), and trace each case to a numbered risk (e.g. R-11 authentication bypass). Weak suites send the same tautology four times or omit the negative control — that omission is the difference being marked, because banning apostrophes "passes" the attack tests while breaking real customers.
11 Self-Check
Click each question to reveal the answer.
Q1: In one sentence, what causes every injection flaw?
Untrusted user input is allowed to change the structure of a command — SQL, a shell command, an LDAP filter — because the command was built as text with the input concatenated in, so the interpreter cannot tell the user’s data from the program’s instructions.
Q2: Why is input sanitisation not a reliable fix for SQL injection?
Blocklists are always incomplete — encodings, alternate syntax, and database-specific tricks get past them. The structural fix is a parameterised query, which keeps user input as data that can never become part of the command, regardless of what is typed. Sanitisation is at best a second layer.
Q3: What is a parameterised query and why does it stop injection?
A query written with placeholders (e.g. ? or named markers) where the user values are passed separately rather than glued into the string. The database treats those values as pure data, so an injection payload is searched for as a literal value and never executed as part of the command.
Q4: Why must an injection test suite include a negative-control case?
Because a lazy “fix” that simply bans apostrophes or special characters blocks the attack but also breaks legitimate inputs like O’Brien. A negative control — a valid but tricky input that must still succeed — proves the system is both secure and still correct.
Q5: What is blind injection, and why can a clean-looking response still be a fail?
Blind injection returns no error and no obvious data — the attacker infers information from response timing or true/false differences in behaviour. So an absence of SQL errors does not prove safety; you must test for behavioural differences, including a timing payload that must not change response time.
12 Interview Prep
Real questions asked in NZ QA interviews for security-aware testing roles. Read the model answers, then practise your own version.
“How would you test a login form for SQL injection?”
I’d treat the input fields as a channel for instructions, not just data. I’d send a set of payloads — a tautology like ' OR '1'='1, a comment terminator like admin'--, a UNION attempt, and a stacked statement — and confirm every one is rejected with the normal “invalid credentials” response, no login, and no SQL error leaked to the client. I’d add a blind-injection check using a timing payload, because a clean response is not proof of safety. And I’d always include a negative control, a legitimate name with an apostrophe like O’Brien that must still log in, so I prove the fix is secure without breaking real users. I’d capture the requests and responses as evidence and trace the cases to a risk in the register.
“A developer says they fixed injection by escaping quotes. Are you satisfied?”
No, not on its own. Escaping a few characters is a blocklist, and blocklists are always incomplete — encodings, database-specific syntax, and inputs that do not need a quote can get past them. I’d ask whether the queries are parameterised, because that is the structural fix that makes injection impossible regardless of input. If they are, escaping is a redundant second layer and fine; if they are not, the escaping is the only thing standing between us and a breach, and I’d treat the flaw as still open and write test cases to demonstrate it.
“Where besides the login would you look for injection in an NZ government services portal?”
Anywhere user input reaches an interpreter. So: search and filter fields, sort parameters, pagination values, hidden form fields, HTTP headers, cookies, and the JSON bodies of any API the portal calls. I’d pay attention to lookups by identifier — an IRD-number or client-number search is a classic spot — and to anything that builds a report or export, because those often assemble queries dynamically. I’d also watch for command injection in any feature that processes files or calls external tools. The reading habit is the same everywhere: find where a command is built from text, then find the input that flows into it.