Safe Penetration Testing Labs · intermediate · ~10 min
- Explain, in plain language, why SQL injection happens: user *data* gets interpreted as query *code*. - Recognise the vulnerable pattern in source code (string concatenation / `sprintf` building a query) versus the safe pattern (a `?` placeholder bound by the driver). - Draw a simple threat model for a database-backed lookup: what asset is protected, where the trust boundary sits, and which insecure assumption enables the attack. - Write a parameterised (prepared-statement) version of a query and explain why it is the only reliable fix — not escaping, not blacklists, not a WAF alone. - Verify a fix defensively: prove the query *rejects* an injection payload as literal data while still *accepting* a legitimate value. - Log the security-relevant facts of a failed lookup without ever logging the injected secret material.
Security objective. The asset you are protecting is the database — the rows in a users table, and everything an attacker could read, change, or delete once they can bend your queries to their will. The threat is SQL injection (SQLi): an attacker supplies input that your program splices into a SQL string, so their text stops being data and becomes code the database executes. By the end of this lesson you will be able to detect the vulnerable coding pattern and prevent it with parameterised queries, then verify that the fix holds.
SQL is a language. When your program builds a query by pasting user input straight into the query text, the database receives one flat string and cannot tell where your intended code ends and the user's data begins. If the user's data happens to contain SQL syntax — a quote, an OR, a comment marker — the engine may execute it. That is the entire bug in one sentence: the boundary between code and data was never actually enforced.
This lesson is on the pentest track, and it is deliberately concept-first. You do not run injection against anyone. You learn to read a query-building function and say "this is exploitable" or "this is safe," and you learn to write the safe version. That skill — reading code for the code/data boundary — is exactly what the two related exercises drill: one asks you to detect a ? placeholder, the other to detect concatenation-based query building.
How this connects to your prior work (prerequisites). You have already seen command injection in the previous lesson in this category (fix-toy-command-injection). SQL injection is the same disease in a different organ: untrusted input crosses a trust boundary into an interpreter (there, a shell; here, a SQL engine). The cure rhymes too — never build the command/query by string-pasting; hand the interpreter the template and the data on separate channels. If you understood why system("ping " + host) is dangerous, you already understand 80% of SQLi.
Everything here runs on localhost, in a throwaway SQLite file or a disposable container you own. Never point these techniques at a system you do not own or lack written authorization to test.
SQL injection is not a museum piece. It has sat on the OWASP Top 10 for over two decades (now folded into the broader "Injection" category), and it still shows up in real breach reports because the vulnerable pattern — build a string, send it to the database — is the easiest thing to write in almost every language. One missed query can expose an entire customer table.
In authorized professional work this matters in several concrete ways:
sprintf/strcat/f-strings that assemble SQL. Being able to spot the smell in seconds keeps vulnerable code out of production.The payoff of understanding the concept (rather than memorising one payload) is that it transfers: the same reasoning protects against LDAP injection, XPath injection, NoSQL injection, and OS command injection. It is one idea — keep code and data on separate channels — that pays off everywhere.
Definition. A SQL statement is a program the database compiles and runs. SELECT, WHERE, OR, -- are syntax with meaning.
Plain explanation. When you write "SELECT * FROM users WHERE name = '" + input + "'", you are generating source code at runtime using a value you do not control. If input contains characters that mean something in SQL, they change the program.
How it works. The database receives the finished string, parses it into a syntax tree, and executes that tree. It has no memory of which characters came from you and which came from the user — by the time it parses, it is all one string.
When / when-not. Concatenation is fine for values you fully control at compile time (rare and still discouraged). It is never acceptable for anything derived from user input, files, headers, other services, or a database you do not own.
Pitfall. "But I only concatenate a number / an internal value." Internal values leak from external sources more often than you think, and numbers passed as strings can still carry payloads. Parameterise unconditionally.
Definition. A trust boundary is the line where data stops being trusted and must be validated or handled safely.
Plain explanation. In a database lookup, the boundary is the moment user input enters your query-building code. Everything the user sends is untrusted; the query template you wrote is trusted. Injection is what happens when untrusted bytes get treated with the trust level of your template.
How it works. Safe designs enforce the boundary by structurally separating template from data — the driver ships the SQL text and the parameter values as different things over the wire, so the data can never re-enter the parser as syntax.
When / when-not. Every place external input reaches an interpreter (SQL engine, shell, HTML renderer) is a boundary. Draw them explicitly.
Pitfall. Thinking the boundary is "the login page." The boundary is every input that reaches a query — search boxes, URL parameters, JSON fields, cookies, HTTP headers, imported files.
Definition. A parameterised query is a SQL template with placeholders (? or :name) whose values are supplied separately and bound by the driver.
Plain explanation. You tell the database: "here is the exact shape of the query; here are the values to slot in." The database compiles the template first, then treats each bound value as pure data. An injection payload bound as a parameter becomes a literal string to search for — it can never become syntax.
How it works. The driver sends the template to the engine, which parses and plans it once. Values are transmitted out-of-band and inserted into the already-parsed plan. There is no re-parsing of user data, so there is nothing to inject into.
When / when-not. Use parameters for every value. You cannot parameterise identifiers (table or column names) — those must be validated against a fixed allowlist, never concatenated from user input.
Pitfall. Building the template itself by concatenation and then binding one value — the concatenated part is still injectable. The whole query text must be static except for the placeholders.
Definition. Escaping = adding backslashes/doubling quotes; blacklisting = rejecting inputs that contain "bad" words like OR or --.
Plain explanation. These try to sanitise data so it is safe to concatenate. They are fragile: encodings, edge cases, and database-specific quoting rules routinely defeat hand-rolled escaping, and blacklists block legitimate input (the name O'Brien) while missing novel payloads.
How it works. They still fundamentally put data on the code channel and hope it stays inert. Prepared statements remove the hope by removing the channel.
When / when-not. Input validation (allowlists, length/type checks) is a valuable defence in depth layer, but it is never the primary control. The primary control is parameterisation.
Pitfall. "We pass a security scanner, so we're safe." A scanner finding nothing means it found nothing — not that the code is safe. And a Web Application Firewall (WAF) blocking a payload is a speed bump, not a fix. Nothing is ever "completely secure"; you reduce risk with layered, structural controls.
UNTRUSTED | TRUSTED (your process)
|
[ User / attacker ] |
| supplies: name = ' OR '1'='1 |
v |
+--------------+ input crosses ===> TRUST BOUNDARY
| Web form / | (validate + treat |
| API / URL | as DATA only) |
+--------------+ |
v
+------------------+
| Query builder | <-- VULN if it does
| (your C / app | "...WHERE name='"+input+"'"
| code) | <-- SAFE if it binds a ? placeholder
+------------------+
|
v template + data
+------------------+
ASSET ------------------------> | SQL engine / |
(users table, all rows, | database file |
PII, password hashes) +------------------+
|
v
[ Audit log: who, when, result ]
Knowledge check.
OR/--; you run payloads only on systems you own because sending them elsewhere is unauthorized access.)The one structural idea to memorise: the SQL text is static; values ride in a separate list. The placeholder character depends on the driver.
// SQLite C API — the safe shape (annotated)
sqlite3_stmt *stmt;
const char *sql = "SELECT id FROM users WHERE name = ?;"; // (1) template: the ? is a placeholder, NOT a quote
sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); // (2) compile the template once
sqlite3_bind_text(stmt, 1, user_input, -1, SQLITE_TRANSIENT); // (3) bind value to placeholder #1 as DATA
sqlite3_step(stmt); // (4) run; user_input can never become syntax
sqlite3_finalize(stmt); // (5) release the compiled statement
Placeholder styles you will meet:
| Driver / language | Placeholder | Example |
|---|---|---|
| SQLite / ODBC (C) | ? |
WHERE name = ? |
| PostgreSQL (libpq) | $1, $2 |
WHERE name = $1 |
| Python DB-API (sqlite3) | ? or %s |
cur.execute(sql, (name,)) |
| Named params | :name |
WHERE name = :name |
The anti-pattern to recognise on sight — never do this:
// WARNING: intentionally vulnerable pattern shown for recognition only.
char sql[256];
sprintf(sql, "SELECT id FROM users WHERE name = '%s'", user_input); // user_input splices into SQL text
Rule of thumb: if the SQL string is assembled with sprintf, strcat, +, or an f-string and any part is user-derived, it is injectable. If the only variable parts are ?/$1/:name placeholders, it is parameterised.
A SQL query is code. When you build that code by gluing user input directly into the query string, the user can sneak their own SQL into it.
Here is the dangerous pattern:
"SELECT * FROM users WHERE name = '" + input + "'"
The query expects input to be a value, such as a name. But the user controls input, and the database has no way to tell where your code ends and their data begins.
Suppose the user supplies this as input:
' OR '1'='1
The query now becomes:
SELECT * FROM users WHERE name = '' OR '1'='1'
The condition '1'='1' is always true, so the query returns every user. The attacker injected SQL syntax that changed what the query does. This is SQL injection.
The only reliable defence is a parametrised query (also called a prepared statement).
With a prepared statement:
Because code and data travel on separate channels, injected syntax stays inert.
The C exercises in this track do not actually run SQL injection. Instead, they teach you to:
The example uses SQLite because it needs no server — it lives in a single local file you own, which is exactly the isolated, authorized lab this lesson requires. It shows the INSECURE build, the SECURE fix, and a VERIFY step that proves the fix rejects an injection payload while still accepting a real name.
Authorization checklist (before running anything):
/* sqli_demo.c
* Build: cc -std=c11 -Wall -Wextra sqli_demo.c -lsqlite3 -o sqli_demo
* Run : ./sqli_demo
* A self-contained, local, authorized lab. No network. Delete demo.db when done.
*/
#include <stdio.h>
#include <string.h>
#include <sqlite3.h>
/* ---- helper: run a raw string as one statement, report rows returned ---- */
static int count_rows_raw(sqlite3 *db, const char *sql) {
sqlite3_stmt *st = NULL;
int rc = sqlite3_prepare_v2(db, sql, -1, &st, NULL);
if (rc != SQLITE_OK) { fprintf(stderr, "prepare failed: %s\n", sqlite3_errmsg(db)); return -1; }
int rows = 0;
while (sqlite3_step(st) == SQLITE_ROW) rows++;
sqlite3_finalize(st);
return rows;
}
/* ============================================================
* WARNING: intentionally vulnerable — use only in a local,
* isolated, authorized lab. Do not deploy.
* Builds the query by splicing user input into SQL text.
* ============================================================ */
static int lookup_insecure(sqlite3 *db, const char *name) {
char sql[256];
snprintf(sql, sizeof(sql),
"SELECT id FROM users WHERE name = '%s';", name); /* <-- injection point */
printf(" [insecure] query: %s\n", sql);
return count_rows_raw(db, sql);
}
/* ---- SECURE: parameterised query; value is bound as data ---- */
static int lookup_secure(sqlite3 *db, const char *name) {
const char *sql = "SELECT id FROM users WHERE name = ?;";
sqlite3_stmt *st = NULL;
if (sqlite3_prepare_v2(db, sql, -1, &st, NULL) != SQLITE_OK) {
fprintf(stderr, "prepare failed: %s\n", sqlite3_errmsg(db));
return -1;
}
sqlite3_bind_text(st, 1, name, -1, SQLITE_TRANSIENT); /* name is DATA, never syntax */
int rows = 0;
while (sqlite3_step(st) == SQLITE_ROW) rows++;
sqlite3_finalize(st);
return rows;
}
int main(void) {
sqlite3 *db = NULL;
if (sqlite3_open("demo.db", &db) != SQLITE_OK) {
fprintf(stderr, "open failed: %s\n", sqlite3_errmsg(db));
return 1;
}
/* seed a tiny table */
char *err = NULL;
const char *seed =
"DROP TABLE IF EXISTS users;"
"CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT);"
"INSERT INTO users(name) VALUES('alice'),('bob'),('carol');";
if (sqlite3_exec(db, seed, NULL, NULL, &err) != SQLITE_OK) {
fprintf(stderr, "seed failed: %s\n", err); sqlite3_free(err);
sqlite3_close(db); return 1;
}
const char *good = "alice";
const char *evil = "' OR '1'='1"; /* classic tautology payload (lab only) */
printf("INSECURE build:\n");
printf(" good input 'alice' -> %d row(s) (expect 1)\n", lookup_insecure(db, good));
printf(" evil input \"' OR '1'='1\" -> %d row(s) (expect 3 = ALL users -> INJECTION!)\n",
lookup_insecure(db, evil));
printf("\nSECURE build:\n");
printf(" good input 'alice' -> %d row(s) (expect 1)\n", lookup_secure(db, good));
printf(" evil input \"' OR '1'='1\" -> %d row(s) (expect 0 = treated as a literal name)\n",
lookup_secure(db, evil));
/* ---- VERIFY: assert the fix rejects bad and accepts good ---- */
int ok = 1;
if (lookup_secure(db, good) != 1) { printf("FAIL: secure query rejected a valid user\n"); ok = 0; }
if (lookup_secure(db, evil) != 0) { printf("FAIL: secure query leaked rows to a payload\n"); ok = 0; }
printf("\nVERIFY: %s\n", ok ? "PASS — payload treated as data, real name still works" : "FAIL");
sqlite3_close(db);
return ok ? 0 : 2;
}
Expected behaviour. The insecure path returns 1 row for alice but 3 rows for the payload — every user leaks, because WHERE name = '' OR '1'='1' is always true. The secure path returns 1 for alice and 0 for the payload, because the database searches for a user literally named ' OR '1'='1, which does not exist. The VERIFY block asserts both and prints PASS.
Cleanup / reset: delete the local database file after the exercise:
rm -f demo.db
Walking the key path, insecure then secure:
| Step | Code | What happens |
|---|---|---|
| 1 | snprintf(sql, ..., "...name = '%s';", name) |
The user's name is pasted inside the quotes of the SQL text. This is the trust-boundary crossing. |
| 2 | with name = "alice" |
sql becomes SELECT id FROM users WHERE name = 'alice'; — one match. |
| 3 | with name = "' OR '1'='1" |
sql becomes SELECT id FROM users WHERE name = '' OR '1'='1';. The user's ' closed the string early; OR '1'='1' is now code. |
| 4 | count_rows_raw runs it |
The WHERE is always true, so all 3 rows return. The data became syntax — injection. |
| 5 | prepare_v2(db, "...name = ?;") |
Secure path: the template is compiled with a placeholder. There is no user text in it to parse. |
| 6 | sqlite3_bind_text(st, 1, name, ...) |
name is attached to placeholder #1 as a value, out-of-band from the SQL text. |
| 7 | with name = "' OR '1'='1" |
The engine searches for a row whose name column literally equals the 12-character string ' OR '1'='1. |
| 8 | sqlite3_step loops |
No such row exists → 0 rows. The payload never reached the parser as syntax. |
| 9 | VERIFY block | Asserts secure(good)==1 and secure(evil)==0, proving the fix accepts good and rejects bad. |
The pivotal contrast is step 3 versus step 7. Same bytes of input; in the insecure build they alter the query's structure, in the secure build they are inert data. That difference is the lesson.
Mistake 1 — Parameterising only some values.
snprintf(sql, ..., "SELECT * FROM users WHERE role='%s' AND name=?", role); ... bind(name).role is still concatenated, so it is still injectable. One unbound value is enough.WHERE role = ? AND name = ? and bind both.%s/+/strcat on the query is a red flag.Mistake 2 — Trying to escape your way to safety.
O'Brien. Data still rides the code channel.escape_quotes() feeding a concatenated query, replace the whole thing with a prepared statement.Mistake 3 — Parameterising an identifier.
SELECT * FROM ? WHERE id = ? to make the table name dynamic.if (strcmp(t,"users")&&strcmp(t,"orders")) reject;) then build the query from the allowlisted constant.Mistake 4 — "The scanner/WAF passed, so it's fixed."
Symptom: build fails with undefined reference to sqlite3_open. You forgot to link the library. Compile with -lsqlite3. On Debian/Ubuntu install headers first: sudo apt-get install libsqlite3-dev.
Symptom: prepare failed: near \"'\": syntax error in the insecure path with certain inputs. That is the vulnerability announcing itself — the input broke out of the string and produced malformed SQL. In the secure path this cannot happen because the input is never parsed as SQL. Use it as a teaching signal, not a bug to "fix" by escaping.
Symptom: secure query returns 0 rows for a name you know exists. Check: (a) the bound index matches the placeholder position (SQLite binds are 1-based), (b) you bound before stepping, (c) trailing whitespace/case differences in the stored value. Print the bound value and the row count.
Symptom: it "works" but you are unsure it is really parameterised. Add a probe: pass ' OR '1'='1 and confirm you get 0 rows, then pass a real name and confirm 1. If the payload ever returns everything, the query is still concatenated somewhere.
Questions to ask when a query misbehaves or you suspect injection:
--/OR, does the structure of the query change? (It must not.)Tooling: sqlite3 demo.db opens the file to inspect rows manually; .schema shows the table. A static analyser or linter that flags string-formatted SQL (e.g. Semgrep rules for concatenated queries) catches these in CI.
Security & safety — detection and logging.
What to log for every database lookup at the trust boundary:
user_lookup) — not the raw SQL.What you must never log: passwords, session tokens or cookies, API keys, private keys, full credit-card PANs, and any PII you do not strictly need. Critically, do not log the raw injected payload verbatim into a system that later renders it (log-forging / second-order injection) — store it length-limited and clearly quoted, or store a hash, if you must record it at all.
Events that signal abuse:
', --, /*, UNION, OR 1=1, or unbalanced quotes.admin' --).False positives arise legitimately: the name O'Brien, an address with --, a search for the literal word "OR", or a code snippet submitted to a bug tracker. This is exactly why blacklisting inputs is a poor primary control and why alerts must be tuned: parameterisation makes those inputs safe (they are just data), while your monitoring flags only patterns and rates, not single benign quotes. Alert on volume and anomaly, confirm with context, and never treat a scanner hit or a single suspicious character as proof of compromise.
Authorized real-world use case. An e-commerce team hardens its product-search endpoint. During an authorized internal review, an engineer finds the search builds SQL with an f-string. They (1) reproduce the risk in a local copy of the database, (2) confirm a payload alters the query, (3) replace it with a parameterised query, (4) verify the payload now returns zero rows while normal searches still work, and (5) add a Semgrep CI rule so concatenated SQL can never merge again. No customer data left the developer's machine; the fix is structural.
Professional best-practice habits:
SELECT/INSERT/UPDATE what it needs — never DROP, and never run as the DB admin. This caps the blast radius if something slips.Beginner vs advanced:
| Beginner focus | Advanced focus | |
|---|---|---|
| Detection | Spot concatenation vs ? placeholder in source |
Trace tainted data across functions/ORMs; catch second-order injection |
| Fix | Convert one query to a prepared statement | Enforce parameterisation org-wide via linters, code review, safe query builders |
| Verify | Payload returns 0 rows, real input still works | Automated regression tests + CI static analysis + least-privilege DB roles |
| Reporting | "This query is concatenated → fix" | Severity by exploitability/impact, remediation + retest evidence |
Remember: passing a scanner or sitting behind a WAF does not prove safety, and decoding a token (like a JWT) is not the same as verifying it. Structural fixes plus verification are what count.
All tasks are lab-only, on a local database file or container you own. Each ends by remediating and verifying — never by attacking anything you do not own.
Beginner 1 — Spot the smell.
sprintf, strcat, +, f-string).?/$1/:name placeholder.Beginner 2 — Rewrite to parameterised.
lookup_insecure from this lesson and rewrite it as a prepared statement.sqlite3_prepare_v2, sqlite3_bind_text (1-based index), sqlite3_finalize; check every return code.alice → 1 row; ' OR '1'='1 → 0 rows.?.SQLITE_TRANSIENT.Intermediate 1 — Verify the fix.
demo.db.Intermediate 2 — Detection & logging.
Challenge — Safe dynamic sort (allowlist an identifier).
name or id) safely.? placeholders; the column name must come from a fixed allowlist, never from raw input; reject anything not in the allowlist.sort=name works; sort=name); DROP TABLE users;-- is rejected before any query runs.sort value is rejected and the table still exists afterwards.Defensive conclusion for every task: after finding or fixing a risk, (1) apply the parameterised/allowlist fix, (2) verify the payload is now inert, (3) confirm legitimate input still works, then reset your lab with rm -f demo.db.
Main concepts. SQL injection happens because a SQL statement is code, and building it by pasting user input into the query text lets the user's data become code. The trust boundary is the moment input reaches the query builder; the insecure assumption is "this input is just a value." The classic ' OR '1'='1 payload turns a targeted WHERE into an always-true condition and dumps the table.
Key syntax/commands. The fix is the parameterised query / prepared statement: a static SQL template with ? (or $1, :name) placeholders, values supplied separately via sqlite3_bind_text and friends. Identifiers (table/column names) cannot be bound — validate them against an allowlist. Recognise the smell (sprintf/strcat/+/f-string on SQL) versus the safe pattern (only placeholders vary).
Common mistakes. Parameterising only some values; trying to escape or blacklist instead of parameterising; parameterising an identifier; and treating a passing scanner or a WAF as proof of safety. None of these fix the root cause.
What to remember. Keep code and data on separate channels — always. Verify every fix by proving the payload now returns zero rows while legitimate input still works. Log the security-relevant metadata (timestamp, source, resource, result, decision, correlation id) but never the secrets or the raw payload into a rendered sink. Run these techniques only on systems you own or are authorized to test, on localhost or a disposable lab, and reset the lab afterwards. Nothing is ever "completely secure" — you reduce risk with structural, layered, verified controls.