Secure Coding in C · intermediate · ~15 min
- Explain what SQL injection is and why building queries by concatenating strings is dangerous - Recognise the classic SQL metacharacters and injection markers: `'`, `--`, `/*`, `;`, and `UNION SELECT` - Write a single-pass C scanner that flags suspicious input without modifying it - Perform a case-insensitive keyword match safely on a C string - Understand why this detector is a *defence-in-depth layer*, not a replacement for parameterised queries - Avoid the common trap of "sanitising" input instead of rejecting it
Imagine a login form. The user types a username, and your program builds a database question like "find the row where username equals what they typed." If you build that question by gluing the user's text directly into the SQL command, a clever attacker can type text that changes the command itself rather than just answering it. That attack is called SQL injection, and it is one of the most common and damaging web vulnerabilities in history.
The real, correct cure is a parameterised query (also called a prepared statement): you send the SQL command and the user's data to the database on separate channels, so the data can never be read as code. You will practice that separately. This lesson teaches a complementary skill: a metacharacter detector that walks the input once and raises a flag when it sees characters that have special meaning to a SQL engine, such as a quote or a comment marker.
This builds directly on C strings: you are scanning a NUL-terminated char array one byte at a time, comparing characters, and looking for short substrings. Everything you learned about where a string ends (the '\0' terminator) and how to walk it safely applies here. In plain language first: you are a security guard reading names at a door and noticing anyone whose "name" is actually a set of instructions. In terminology: you are implementing an input validation heuristic that detects SQL metacharacters and injection markers as a defence-in-depth control.
SQL injection has topped vulnerability lists for over two decades. A single unguarded query can let an attacker read every password hash in a database, delete tables, or bypass a login entirely. Understanding why string-built queries fail is essential for any programmer who touches a database.
Parameterised queries are the proper fix, and a metacharacter detector does not replace them. What the detector adds:
UNION SELECT is a live probe against your app, and you want to see it.Learning to write this detector also trains the security mindset: treat all external input as untrusted, look for the specific tokens attackers rely on, and fail safe.
Definition. SQL injection is a vulnerability where untrusted input is placed into a SQL statement in a way that lets the input change the statement's structure or meaning.
Plain-language explanation. Suppose your code builds this query by concatenation:
SELECT * FROM users WHERE name = '<INPUT>';
If a user types alice, the query becomes harmless:
SELECT * FROM users WHERE name = 'alice';
But if the user types ' OR '1'='1, the query becomes:
SELECT * FROM users WHERE name = '' OR '1'='1';
The single quote closed the string early, and OR '1'='1' is always true, so the query returns every user. The data crossed the line from value to code.
How it works internally. The database receives one flat string of SQL text and parses it into a command. It has no way to know which characters came from your program and which came from the user, because they were merged into the same string before parsing. The quote character is the pivot: it is the boundary marker between "still inside a string literal" and "back to SQL keywords."
When to defend this way / when not. Always defend at the query layer with parameterised queries. Use a metacharacter detector as an extra layer and for logging, never as your only defence.
Common pitfall. Believing that removing or escaping bad characters makes concatenation safe. Escaping is fragile and easy to get wrong; the robust answer is to never concatenate user data into SQL at all.
Knowledge check. In SELECT * FROM users WHERE name = '<INPUT>';, which single character does the attacker most need to escape the value and start writing SQL code?
Definition. A metacharacter is a character that has special meaning to the SQL parser rather than being ordinary data. An injection marker is a short token attackers commonly use to steer a query.
The signals this lesson's detector looks for:
| Marker | Meaning to SQL | Why attackers use it |
|---|---|---|
' |
Ends/starts a string literal | Break out of the intended value |
-- |
Line comment (rest of line ignored) | Comment out the tail of your query |
/* |
Block comment start | Hide or truncate query text |
; |
Statement separator | Append a second, malicious statement |
UNION SELECT |
Combine result sets | Steal data from other tables |
How it works internally. Your scanner reads the input byte by byte. For single-character markers (', ;) you compare one char. For two-character markers (--, /*) you check the current character and the next one. For the keyword pair UNION SELECT you match a longer substring, ignoring case because SQL keywords are case-insensitive.
When to use / when not. Detect-and-reject is appropriate for fields that should never contain these characters (usernames, numeric IDs). It is not appropriate to blanket-reject on fields where such characters are legitimate data (a comment box may legitimately contain an apostrophe). That is exactly why parameterised queries — not blacklists — are the real fix.
Common pitfall. Case sensitivity. union select, UNION SELECT, and UnIoN sElEcT are all equivalent to the database. A naive strstr for the exact uppercase form misses the lowercase attack.
input: a d m i n ' -- \0
^ scan position moves left to right
^ single quote -> FLAG
^ ^ '--' -> also would FLAG
return 1 as soon as any marker is found (early exit)
Knowledge check. Why must the UNION SELECT check fold case, when the ' check does not?
Definition. Flagging means classifying input as suspicious and letting the caller decide (reject, log, alert). Sanitising means altering the input to try to make it safe.
Plain-language explanation. This function is a smoke detector, not a fire extinguisher. It reports; it does not repair. Sanitising by stripping characters produces subtle bugs and can be bypassed (for example, an attacker nests admin'-- inside text that survives a naive filter). Rejecting outright is simpler and safer for fields that should be clean.
How it works internally. The function returns 1 (suspicious) or 0 (clean) and never writes to the input. The caller then chooses policy.
When to use / when not. Flag-and-reject at boundaries where the character set is known and narrow. Do not use flagging to "clean" free-text and then concatenate it — that path always leads back to injection.
Common pitfall. Treating a 0 return as "this input is safe to concatenate." It only means "no known markers found." Safe query construction still requires parameterisation.
Knowledge check. Explain in your own words the difference between a detector that returns 1/0 and a sanitiser that rewrites the string. Which one belongs in front of a parameterised query?
Key building blocks. Scan with pointers, compare chars, and check the next byte only after confirming the current one is not the terminator.
#include <ctype.h> // tolower
#include <string.h> // strlen, strncmp
// Case-insensitive compare of the first n bytes of a and b.
static int ci_startswith(const char *a, const char *b, size_t n) {
for (size_t i = 0; i < n; i++) {
// cast to unsigned char before tolower: passing a negative
// char to tolower is undefined behaviour.
int ca = tolower((unsigned char)a[i]);
int cb = tolower((unsigned char)b[i]);
if (ca != cb) return 0;
if (a[i] == '\0') return 0; // a ended early
}
return 1;
}
Notes:
i, only read s[i+1] after you know s[i] != '\0'.tolower((unsigned char)c) — always cast; a plain char may be signed and negative, which is undefined for the ctype.h functions.The real defence against SQL injection is parameterised queries (also called prepared statements): you send the SQL and the user data separately, so the data can never be interpreted as code.
But on the way to that, defenders write input validators that flag suspicious metacharacters early, before the input reaches the query layer. A metacharacter is a character that has special meaning to the SQL engine, such as a quote or a comment marker.
This exercise teaches the metacharacter detector. The proper cure, prepared statements, is mentioned here but practiced separately.
') — the classic injection delimiter-- and /*);)UNION SELECT, matched case-insensitively)Implement:
int has_sqli_markers(const char *input);
Return:
0 — input contains no suspicious patterns1 — input contains at least one suspicious patternThe function is a flagger, not a sanitiser. Real defence is parameterised queries. This function just tells the application: "this input deserves a second look."
#include <stdio.h> #include <ctype.h> #include <string.h>
/* Case-insensitive check: does the text at p begin with the C-string kw? */ static int ci_prefix(const char *p, const char kw) { for (size_t i = 0; kw[i] != '\0'; i++) { if (p[i] == '\0') return 0; / input ended first / if (tolower((unsigned char)p[i]) != tolower((unsigned char)kw[i])) return 0; / mismatch */ } return 1; }
/*
Flag input that contains classic SQL-injection markers.
Returns 1 if any marker is present, otherwise 0.
The input is never modified: this detects, it does not sanitise. */ int has_sqli_markers(const char input) { if (input == NULL) return 0; / nothing to scan */
for (size_t i = 0; input[i] != '\0'; i++) { char c = input[i]; if (c == ''' || c == ';') return 1; /* quote or terminator / / two-char markers: safe to read input[i+1] because input[i] * is not the NUL, so i+1 is at worst the NUL itself. / if (c == '-' && input[i + 1] == '-') return 1; / -- comment / if (c == '/' && input[i + 1] == '') return 1; /* /* comment / / keyword probe, case-insensitive / if (ci_prefix(&input[i], "union select")) return 1; } return 0; / clean */ }
int main(void) { const char samples[] = { "alice", "' OR '1'='1", "admin'--", "bob; DROP TABLE users", "1 UNION SELECT password FROM users", "o'brien", / legitimate apostrophe still flags */ NULL };
for (int i = 0; samples[i] != NULL; i++) {
printf("%-38s -> %d\n",
samples[i], has_sqli_markers(samples[i]));
}
return 0;
}
**What it does.** `has_sqli_markers` scans a NUL-terminated string once. It returns `1` the instant it finds a single quote, a semicolon, a `--` or `/*` comment start, or the keyword pair `union select` (any case). Otherwise it returns `0`. The `main` runs six sample strings through it.
**Expected output:**
```text
alice -> 0
' OR '1'='1 -> 1
admin'-- -> 1
bob; DROP TABLE users -> 1
1 UNION SELECT password FROM users -> 1
o'brien -> 1
Edge cases. A NULL pointer returns 0 (nothing to flag) rather than crashing. The legitimate name o'brien is flagged too — a reminder that blacklists produce false positives, which is why this is a layer, not the cure. An empty string "" returns 0.
ci_prefix(p, kw) compares the text starting at p against keyword kw, one character at a time, folding case with tolower. If the input ends ('\0') before the keyword does, it returns 0 (no false match past the string end). It returns 1 only when every keyword character matches.has_sqli_markers, the NULL guard returns 0 immediately so we never dereference a null pointer.for loop condition input[i] != '\0' stops exactly at the terminator — we never read past the string.c == '\'' || c == ';' catches the two single-character markers and returns at once.-- and /*, we read input[i + 1]. This is safe because the loop guarantees input[i] is not the NUL; therefore i + 1 indexes at worst the NUL byte itself, which is a valid position, not past the end.ci_prefix(&input[i], "union select") probes for the keyword starting at the current position, case-insensitively.0.Trace for input admin'--:
| i | input[i] | check result | action |
|---|---|---|---|
| 0 | a |
not ',;,-,/; keyword no |
continue |
| 1 | d |
no | continue |
| 2 | m |
no | continue |
| 3 | i |
no | continue |
| 4 | n |
no | continue |
| 5 | ' |
equals ' |
return 1 |
The scan stops at position 5 the moment the quote appears; the -- is never reached because the function already returned.
Mistake 1 — Case-sensitive keyword match.
if (strstr(input, "UNION SELECT")) return 1; // WRONG: misses lowercase
Why it is wrong: SQL keywords are case-insensitive, so union select slips through. Corrected: fold case during the compare (the ci_prefix helper). Recognise it by testing your detector with a lowercase payload — if 1 union select x returns 0, you have this bug.
Mistake 2 — Reading past the terminator when checking two-char markers.
for (size_t i = 0; i <= strlen(input); i++) // WRONG boundary
if (input[i] == '-' && input[i+1] == '-') ... // reads past end
Why it is wrong: when i is the last valid index, input[i+1] is the NUL (fine), but with a <= loop i can reach the NUL and input[i+1] reads one byte past the array. Corrected: loop while input[i] != '\0' so the current byte is always a real character and i+1 is at worst the NUL. Recognise it with AddressSanitizer or Valgrind reporting a heap/stack overread.
Mistake 3 — Passing a raw char to tolower.
if (tolower(input[i]) == tolower(kw[i])) ... // WRONG on signed char
Why it is wrong: char may be signed; a byte like 0xE9 becomes negative, and tolower is only defined for unsigned char values and EOF. Corrected: tolower((unsigned char)input[i]). Recognise it as crashes or garbage results on non-ASCII input.
Mistake 4 — Trying to sanitise instead of reject.
// WRONG mindset: strip quotes then concatenate into SQL
remove_quotes(input);
sprintf(query, "... WHERE name = '%s'", input);
Why it is wrong: character-stripping blacklists are routinely bypassed, and the query is still built by concatenation. Corrected: reject flagged input and use a parameterised query for the rest. Recognise it whenever you see user data glued into a SQL string.
Compiler errors.
#include <ctype.h>.'\'' incorrectly; the character literal for a single quote is '\''.Runtime errors.
input[i+1]) ran past the string. Confirm your loop stops at the NUL, not after it. Build with -fsanitize=address -g and rerun.NULL input; verify the null guard is present.Logic errors.
0 for an obvious payload: print each character and the check result inside the loop, or test the smallest failing input (e.g. just "'"). If lowercase keywords slip through, your compare is case-sensitive.1 for clean input: check for an off-by-one in ci_prefix that matches a shorter prefix than intended.Questions to ask when it doesn't work.
input[i] is a real character before I read input[i+1]?unsigned char before every ctype.h call?NULL, lowercase keywords, and a legitimate apostrophe?This is a security lesson, so both memory safety and injection defence matter.
Memory / undefined behaviour for this code:
input[i + 1]. They are safe only because the loop condition proves input[i] is not the NUL, so i + 1 is a valid index (at worst the NUL). Never combine a look-ahead with a <= or strlen-based loop that lets i sit on the terminator.input is a proper C string. If a caller hands you a non-terminated buffer, the loop runs off the end — a classic overread. In real code, prefer a length-bounded variant that also takes a size_t len.ctype.h domain. Always cast to unsigned char before tolower/isalpha/etc. Passing a negative char is undefined behaviour.const char * and never modifies or frees the input, so there are no lifetime or ownership hazards here.Defensive / injection practices:
sprintf(q, "... name='%s'", user) — is injectable. The fix: a parameterised query where the driver binds user as data (e.g. a ? placeholder), so it is never parsed as SQL.DROP, no access to unrelated tables), limiting damage if a query is ever abused.Concrete use cases.
Professional best-practice habits.
Beginner:
has_sqli_markers) and comment only the non-obvious lines.NULL.Advanced:
const char *s, size_t len) so the code is safe even on non-NUL-terminated data.Beginner 1 — Single-quote and semicolon flag.
Objective: write int has_quote_or_semicolon(const char *s) returning 1 if s contains ' or ;, else 0.
Requirements: single pass; stop at the NUL; handle NULL by returning 0.
Example: "alice" -> 0, "a'b" -> 1, "a;b" -> 1.
Hint: one for loop, two comparisons. Concepts: string scanning, char comparison.
Beginner 2 — Two-character comment markers.
Objective: extend the scan to also flag -- and /*.
Requirements: use a safe look-ahead — only read s[i+1] after confirming s[i] != '\0'.
Example: "admin--" -> 1, "a/*b" -> 1, "a-b" -> 0.
Constraint: no reads past the terminator. Hint: check s[i] first, then s[i+1]. Concepts: look-ahead, bounds care.
Intermediate 1 — Case-insensitive keyword.
Objective: add detection of union select in any case.
Requirements: implement a ci_prefix-style helper using tolower((unsigned char)...); match the two-word keyword with a single space between the words.
Example: "1 UNION SELECT x" -> 1, "1 union select x" -> 1, "reunion selecting" -> ? (decide and justify).
Hint: match the exact substring "union select". Concepts: case folding, substring match.
Intermediate 2 — Length-bounded, safer version.
Objective: write int has_sqli_markers_n(const char *s, size_t len) that scans at most len bytes and does not require NUL termination.
Requirements: never read s[i] for i >= len; guard the look-ahead with i + 1 < len.
Example: same flags as before, but safe on a non-terminated buffer.
Hint: replace s[i] != '\0' with i < len. Concepts: bounded scanning, memory safety.
Challenge — Detector plus safe-query demo.
Objective: build a small program that (a) validates a username with your detector and rejects+logs it if flagged, and (b) shows, in comments or pseudocode, how the accepted username would be passed to a parameterised query rather than concatenated.
Requirements: print a clear ACCEPT/REJECT decision and the reason; keep all data lab-only; do not connect to a real database.
Constraints: the parameterised path must never build SQL by string concatenation.
Hint: model the query as "SELECT * FROM users WHERE name = ?" with the value bound separately. Concepts: layered defence, flag-vs-sanitise, parameterisation.
SQL injection happens when untrusted input is merged into a SQL command and gets parsed as code instead of data. The proper cure is the parameterised query, which sends command and data on separate channels. This lesson adds a complementary layer: a metacharacter detector.
Key ideas:
'\0' terminator.', ;, --, /*, and union select (case-insensitively).1/0 and let the caller reject and log; never strip characters and concatenate.Most important syntax: guard the two-character look-ahead by looping while input[i] != '\0' so input[i+1] is always in bounds, and always cast to unsigned char before tolower.
Common mistakes: case-sensitive keyword matching, reading past the terminator, un-cast ctype.h calls, and the mindset trap of sanitising instead of rejecting.
Remember: this detector is one layer of defence in depth and a source of log signal. Parameterised queries plus least-privilege database accounts remain the real defence.