Biterra Labs preview / Intro to Web Exploitation
SQL injection happens when an application builds a SQL query from user input without keeping that input as data. If the input can close a quote or add a clause, it can change what the database is asked to do.
A login form that concatenates strings looks like this in the backend:
query = (
"SELECT * FROM users WHERE username = '"
+ username
+ "' AND password = '"
+ password
+ "'"
)
const query =
"SELECT * FROM users WHERE username = '" +
username +
"' AND password = '" +
password +
"'";
With ordinary input the quotes stay paired. If the username is alice and the password is secret, the query is:
SELECT * FROM users WHERE username = 'alice' AND password = 'secret'
Each value sits between its own quotes. The AND still joins two real checks.
If the username is:
admin' OR '1'='1' --
the query becomes:
SELECT * FROM users WHERE username = 'admin' OR '1'='1' --' AND password = '...'
Read that WHERE left to right:
| Piece | What it does |
|---|---|
username = 'admin' | ordinary equality. True only if that user exists |
OR | the WHERE is true if either side is true |
'1'='1' | two identical strings. Always true |
-- | comments to the end of the line in SQLite and PostgreSQL. MySQL requires whitespace after -- |
' AND password = '...' | now a comment. Never checked |
Because the OR side is always true, the WHERE matches without a correct password. The database returns a row — often the first user, or admin if that row exists. The application treats that row as a successful login.
| Role | Examples | How the database treats it |
|---|---|---|
| Data | alice, secret | Values to compare; they do not change the query structure |
| Syntax | ', AND, OR, -- | Instructions that control how the query is parsed and executed |
In admin' OR '1'='1' --, the first ' is the boundary: it closes the username value, and the remaining characters are parsed as SQL syntax. The input changes the query itself, not merely the username being compared.
That payload fits this query shape. Other queries need a different close-quote, comment, or clause.
A parameterised query (prepared statement) sends the SQL and the values separately. The SQL is compiled first. The values are bound afterwards, as data. They never go through the SQL parser.
cursor.execute(
"SELECT * FROM users WHERE username = ? AND password = ?",
(username, password),
)
const statement = db.prepare(
"SELECT * FROM users WHERE username = ? AND password = ?",
);
const user = statement.get(username, password);
? is a placeholder. SQLite fills it with the bound value. The quote characters are added by the database, not by string concatenation.
If username is admin' OR '1'='1' -- and password is x, the statement the database runs is still:
SELECT * FROM users WHERE username = ? AND password = ?
with two bound values:
| Placeholder | Value (data only) |
|---|---|
first ? | admin' OR '1'='1' -- |
second ? | x |
The ' , OR, and -- are characters in a username. The database looks for a user whose name is that whole string. There is no extra OR, and the password check is still there.
Placeholder syntax depends on the driver:
| Driver | Placeholder |
|---|---|
SQLite (sqlite3) | ? |
| PostgreSQL (psycopg) | %s |
PostgreSQL (node-postgres) | $1, $2, ... |
| Many others | :username |
# PostgreSQL
cursor.execute(
"SELECT * FROM users WHERE username = %s AND password = %s",
(username, password),
)
// PostgreSQL with node-postgres
const result = await client.query(
"SELECT * FROM users WHERE username = $1 AND password = $2",
[username, password],
);
ORMs can handle parameter binding for you, but only when you use their value APIs. For example, the Django ORM keeps this value separate from the generated SQL:
User.objects.filter(username=username)
SQLAlchemy provides the same protection through bound parameters. An ORM does not make concatenated raw SQL safe. This Django example is still vulnerable:
User.objects.raw("SELECT * FROM users WHERE username = '" + username + "'")
When raw SQL is necessary, follow the ORM's parameter API rather than inserting the value into the SQL string.
The login query above is deliberately simplified to teach parameter binding. Real applications should store a password hash, fetch the user by username with a parameterised query, and verify the submitted password with a password-hashing function. They should not store or compare plaintext passwords in SQL.
Start with a baseline request:
POST /login HTTP/1.1
Content-Type: application/x-www-form-urlencoded
username=admin&password=wrong
Record the status, response length, message, and whether the page redirects. Then change only the username:
POST /login HTTP/1.1
Content-Type: application/x-www-form-urlencoded
username=admin%27&password=wrong
%27 is a URL-encoded single quote. It does not have to produce a visible SQL error. The application may catch the error and return the same "invalid credentials" page.
If the behaviour is interesting, compare a condition that is true with the same condition made false:
admin' AND '1'='1' --
admin' AND '1'='2' --
Send both with the same password and session, then compare them with the baseline:
WHERE clause. The true version allows a row to match; the false version prevents that match.SQL injection does not always produce a login bypass, visible data, or a database error. When the query result is hidden, differences in page behaviour or response time can still reveal whether a condition was true; this is called blind SQL injection.
| Technique | What you observe | Important caution |
|---|---|---|
| Error-based | Database errors reveal query details or data | Many applications hide errors; one server error does not prove SQL injection |
| Boolean-based blind | A true condition and a false condition change the page differently | Compare controlled pairs and keep the rest of the request identical |
| Time-based blind | A condition appears to cause a deliberate database delay even though the page content stays the same | Networks are noisy; compare repeated baseline, true, and false requests before trusting timing |
UNION-based | Results from an injected query appear inside the original page | The column count and compatible data types must fit the original query |
| Second-order | Input is stored safely at first but later inserted unsafely into another SQL query | Test the later use of the stored value, not only the form that accepted it |
These are alternatives, not steps. Choose based on the query context and what changes in the response.
Only practise against systems you own or are explicitly authorised to test, such as this lab.
A quote still gives the same "invalid credentials" page. Safe?
Not necessarily. The app may just be hiding the error. Try one small boolean-shaped change and compare.
Krusty Krab Time Clock
Before typing payloads, inspect the normal login request and note:
Then test one small SQL-shaped change at a time. A quote that still shows the same failure page does not mean the query is safe.