cybersecurity · beginner · ~15 min · safe pentest lab
Understand why parameterized queries cannot secure SQL identifiers, and learn to write a strict deny-by-default allowlist validator with correct first-char/rest-char rules and length bounds, checking NULL and terminator before any dereference.
Parameterized queries (bound ? placeholders) protect values in a SQL statement, but they cannot protect identifiers — table names, column names, ORDER BY targets. If your app builds SELECT * FROM " + table from user input, an attacker who controls table can inject arbitrary SQL: users; DROP TABLE users--, user"name, or a schema-qualified public.secrets. Since identifiers can't be parameterized, the only safe defense is a strict allowlist on the shape of the string before it ever touches the query builder.
Implement int valid_sql_identifier(const char *id). Return 1 iff id is a safe SQL identifier and 0 otherwise. A safe identifier:
A-Z/a-z) or _,[A-Za-z0-9_],NULL and the empty string return 0. This is deny-by-default: anything that isn't provably safe is rejected. The function only inspects the fixed C string it is given — it never touches a database, network, or filesystem.
A leading digit (1users), embedded spaces or punctuation (user-name, public.users), quotes (user"name), a 65-character string, and NULL/empty all must be rejected. A bare _, a single letter, and a maxed-out 64-char name are all accepted.
valid_sql_identifier("users") -> 1valid_sql_identifier("1users") -> 0valid_sql_identifier("") -> 0A single NUL-terminated C string id (may be NULL). It represents an untrusted candidate SQL identifier taken from user input.
Return int 1 if id is a safe SQL identifier per the allowlist rules, otherwise 0. No output is printed.
C11, freestanding function (the harness supplies all includes). Do not allocate memory, read files, or call into any database. Treat id as fully attacker-controlled: check for NULL and stop at the terminating NUL. Valid length range is 1..64 characters; strings of 65+ characters must be rejected. Must be memory-safe under AddressSanitizer/UBSan.
int valid_sql_identifier(const char *id){
/* TODO: implement the allowlist check.
Return 1 only if id is a safe SQL identifier:
- first char is a letter or '_'
- remaining chars are [A-Za-z0-9_]
- length is 1..64
Reject NULL and empty. Deny by default.
This stub is insecure (accepts everything) and must be replaced. */
(void)id;
return 1;
}
Forgetting the NULL check and dereferencing a null pointer. Treating the empty string as valid because the loop body never runs. Allowing a leading digit by using the same character class for the first char as for the rest. Using isalpha/isalnum without guarding against negative char values, or forgetting they also accept locale-specific extras. Checking length with a separate strlen pass and letting a >64 string through, or using < where <= (or vice-versa) is needed at the 64 boundary. Accepting . to allow schema-qualified names — that reopens injection.
NULL pointer -> 0. Empty string -> 0. Single valid char ("a") -> 1. Leading underscore ("_x") -> 1. Leading digit ("1users") -> 0. Exactly 64 chars -> 1. Exactly 65 chars -> 0. Embedded space, dash, dot, quote, or semicolon -> 0. Injection payload "users; DROP TABLE users--" -> 0.
Solve this exercise in the browser editor — compile and run against the test harness, no setup required.