Biterra Labs preview / Intro to Web Exploitation

Intro to SQL Injection

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.

How it happens

A login form that concatenates strings looks like this in the backend:

Python
query = (
    "SELECT * FROM users WHERE username = '"
    + username
    + "' AND password = '"
    + password
    + "'"
)
JavaScript
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:

sql
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:

Text
admin' OR '1'='1' --

the query becomes:

sql
SELECT * FROM users WHERE username = 'admin' OR '1'='1' --' AND password = '...'

Read that WHERE left to right:

PieceWhat it does
username = 'admin'ordinary equality. True only if that user exists
ORthe 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.

RoleExamplesHow the database treats it
Dataalice, secretValues 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.

Parameterised queries

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.

SQLite example

Python
cursor.execute(
    "SELECT * FROM users WHERE username = ? AND password = ?",
    (username, password),
)
JavaScript
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:

sql
SELECT * FROM users WHERE username = ? AND password = ?

with two bound values:

PlaceholderValue (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.

Other database placeholders

Placeholder syntax depends on the driver:

DriverPlaceholder
SQLite (sqlite3)?
PostgreSQL (psycopg)%s
PostgreSQL (node-postgres)$1, $2, ...
Many others:username
Python
# PostgreSQL
cursor.execute(
    "SELECT * FROM users WHERE username = %s AND password = %s",
    (username, password),
)
JavaScript
// PostgreSQL with node-postgres
const result = await client.query(
  "SELECT * FROM users WHERE username = $1 AND password = $2",
  [username, password],
);

ORMs and raw SQL

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:

Python
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:

Python
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.

Real password handling

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.

Worked example

Start with a baseline request:

HTTP
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:

HTTP
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:

Text
admin' AND '1'='1' --
admin' AND '1'='2' --

Send both with the same password and session, then compare them with the baseline:

Other SQL injection shapes

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.

TechniqueWhat you observeImportant caution
Error-basedDatabase errors reveal query details or dataMany applications hide errors; one server error does not prove SQL injection
Boolean-based blindA true condition and a false condition change the page differentlyCompare controlled pairs and keep the rest of the request identical
Time-based blindA condition appears to cause a deliberate database delay even though the page content stays the sameNetworks are noisy; compare repeated baseline, true, and false requests before trusting timing
UNION-basedResults from an injected query appear inside the original pageThe column count and compatible data types must fit the original query
Second-orderInput is stored safely at first but later inserted unsafely into another SQL queryTest 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.

Common mistakes

Tools and resources

Only practise against systems you own or are explicitly authorised to test, such as this lab.

Self-check

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.

Next

Krusty Krab Time Clock

Before you start

Before typing payloads, inspect the normal login request and note:

  • which field is reflected in the response
  • whether the app behaves differently on bad input
  • what a normal failure looks like

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.

Open Krusty Krab Time Clock