Lab 03: SQL Injection โ Vulnerable vs Secure Implementation¶
| Field | Details |
|---|---|
| Course | SCIA-340 โ Database Security |
| Week | 3 |
| Difficulty | โญโญโญ Intermediate |
| Estimated Time | 75 minutes |
| Topic | SQL Security and SQL Injection |
| Prerequisites | Labs 01โ02 complete; understanding of basic PL/pgSQL syntax |
| Deliverables | Screenshots at each checkpoint + verification script output + secure search function |
Overview¶
SQL injection remains the #1 database attack vector in the OWASP Top 10. In this lab you will:
- Build a realistic application database with users and products.
- Write a vulnerable login function that uses string concatenation.
- Exploit it โ demonstrating authentication bypass and UNION-based data extraction.
- Rewrite it securely using parameterized queries and prove the injection fails.
- Encounter second-order SQL injection โ the harder-to-find variant.
The act of exploiting your own vulnerable code before fixing it creates lasting intuition about why parameterized queries are non-negotiable.
Branch Requirement
All SQL must be executed on your Neon branch. Name your branch lab-03 before starting. This lab creates schemas and functions โ running on the wrong branch will cause the verification script to fail.
Neon Setup โ How to Connect
- Log in to https://neon.tech and select your
lab-03branch. - Copy the connection string from Dashboard โ Connection Details.
- Connect:
Part 1 โ Build the Target Database¶
Step 1.1 โ Create Schema and Tables¶
We create a realistic application schema with a users table and a products table that contains a sensitive column (internal_cost) that should never be exposed to end users.
CREATE SCHEMA IF NOT EXISTS app;
CREATE TABLE app.users (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user'
CHECK (role IN ('user','admin','superadmin')),
password_hash TEXT NOT NULL
);
CREATE TABLE app.products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
price NUMERIC(10,2) NOT NULL,
internal_cost NUMERIC(10,2) -- SENSITIVE: must never be exposed via API
);
INSERT INTO app.users (username, email, role, password_hash) VALUES
('alice', 'alice@corp.com', 'user', 'hash_alice'),
('bob', 'bob@corp.com', 'admin', 'hash_bob'),
('eve', 'eve@corp.com', 'user', 'hash_eve'),
('sysadm', 'admin@corp.com', 'superadmin', 'hash_sysadm');
INSERT INTO app.products (name, price, internal_cost) VALUES
('Widget A', 29.99, 4.50),
('Widget B', 49.99, 8.20),
('Gadget X', 99.99, 22.00);
Verify the data loaded correctly:
Part 2 โ Vulnerable Function¶
Step 2.1 โ Build the Vulnerable Login Function¶
This function simulates how many older applications authenticate users: by building a SQL string through concatenation. This is the wrong way to do it.
CREATE OR REPLACE FUNCTION app.login_vulnerable(
p_username TEXT,
p_password TEXT
) RETURNS TEXT AS $$
DECLARE
v_result TEXT;
BEGIN
-- VULNERABLE: user input concatenated directly into SQL string
EXECUTE
'SELECT username FROM app.users WHERE username = ''' || p_username ||
''' AND password_hash = ''' || p_password || ''''
INTO v_result;
RETURN COALESCE(v_result, 'Login failed');
END;
$$ LANGUAGE plpgsql;
Step 2.2 โ Normal Usage Works¶
Confirm the function behaves correctly with legitimate input:
Expected: alice
Step 2.3 โ SQL Injection: Bypass Authentication¶
The classic SQL injection: close the string, append OR TRUE, comment out the rest.
-- Inject: close the string, add OR TRUE, comment out the rest
SELECT app.login_vulnerable($$ ' OR '1'='1' -- $$, 'anything');
Expected: Returns a valid username even though 'anything' is not a real password โ authentication is bypassed entirely.
The SQL the function actually executes becomes:
The WHERE clause always evaluates TRUE, returning the first user in the table.
๐ธ Screenshot checkpoint: Capture the terminal output showing a username returned despite providing a wrong password.
Step 2.4 โ UNION Injection: Extract Data from Another Table¶
UNION-based injection reaches beyond the intended table to extract data the application never meant to expose.
-- Extract internal_cost from products โ a column that should NEVER be visible
SELECT app.login_vulnerable(
$$ ' UNION SELECT CAST(internal_cost AS TEXT) FROM app.products LIMIT 1 -- $$,
'anything'
);
Expected: Returns a value like 4.5 โ the internal_cost of the first product. This column was never intended to be visible through the login endpoint.
๐ธ Screenshot checkpoint: Capture the output showing an internal_cost value returned through the login function.
Part 3 โ Secure Parameterized Implementation¶
Step 3.1 โ Build the Secure Function¶
The fix is conceptually simple but architecturally fundamental: never concatenate user input into SQL strings. Use the variable directly as a query parameter.
CREATE OR REPLACE FUNCTION app.login_secure(
p_username TEXT,
p_password TEXT
) RETURNS TEXT AS $$
DECLARE
v_result TEXT;
BEGIN
-- SECURE: p_username and p_password are bound as data values,
-- never parsed as SQL syntax. No injection possible.
SELECT username INTO v_result
FROM app.users
WHERE username = p_username
AND password_hash = p_password;
RETURN COALESCE(v_result, 'Login failed');
END;
$$ LANGUAGE plpgsql;
Why This Works
In the secure version, p_username is used directly in a static SELECT statement. PostgreSQL parses and plans the query before substituting the parameter value. By the time the value is used, the query structure is already locked โ there is no SQL left to inject into.
Step 3.2 โ Verify Normal Login Still Works¶
Expected: alice
Step 3.3 โ Verify Injection Is Blocked¶
Expected: Login failed โ the injection payload is treated as a literal string value, not SQL. PostgreSQL searches for a user whose username is literally ' OR '1'='1' --, finds none, and returns the failure message.
๐ธ Screenshot checkpoint: Capture the terminal showing Login failed returned for the injection attempt against the secure function.
Step 3.4 โ Verify UNION Injection Is Also Blocked¶
SELECT app.login_secure(
$$ ' UNION SELECT CAST(internal_cost AS TEXT) FROM app.products LIMIT 1 -- $$,
'anything'
);
Expected: Login failed โ the UNION payload is also treated as a literal string. No data from app.products is accessible.
๐ธ Screenshot checkpoint: Capture the output confirming Login failed for the UNION injection attempt.
Part 4 โ Second-Order SQL Injection Demo¶
Second-order (stored) SQL injection is the more insidious variant: malicious input is stored safely in the database, but later retrieved and fed into a vulnerable query โ triggering the injection at that later point.
Step 4.1 โ Demonstrate Second-Order Injection Concept¶
-- Step 1: Store malicious data safely via a parameterized INSERT
-- The dollar-quoted literal is stored as-is โ not executed at insert time
INSERT INTO app.users (username, email, role, password_hash)
VALUES (
$$ admin'-- $$, -- looks like SQL but stored safely as a string
'attacker@evil.com',
'user',
'hash_attacker'
);
-- Step 2: Confirm the data was stored correctly (not executed)
SELECT id, username, email
FROM app.users
WHERE email = 'attacker@evil.com';
The username admin'-- is stored verbatim. Safe so far.
-- Step 3: A later VULNERABLE query retrieves the stored value and injects it
-- This simulates a "change password" or "lookup by stored username" operation
SELECT app.login_vulnerable(
(SELECT username FROM app.users WHERE email = 'attacker@evil.com'),
'anything'
);
The retrieved username admin'-- is now concatenated into the vulnerable function's SQL string, producing:
The -- comments out the password check, returning the admin account.
Why Second-Order Is Hard to Find
Input validation and parameterization at the insertion point cannot prevent this if a different code path later uses the stored value in a vulnerable query. Testing requires tracing data from storage back through every downstream usage โ not just testing the input point.
๐ธ Screenshot checkpoint: Capture the output showing the second-order injection triggering on the stored malicious username.
Cleanup / Reset¶
To remove all objects created in this lab:
Warning
This drops the app schema, all tables, and all functions. The verification script will fail if run after this reset. Only reset if explicitly re-doing the lab.
Assessment¶
Verification Script¶
Dr. Chen will run the following script against your Neon lab-03 branch connection string.
-- VERIFY LAB 03
SELECT
-- Vulnerable function exists in app schema
(SELECT COUNT(*)
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'app'
AND p.proname = 'login_vulnerable')::INT AS vuln_function_exists,
-- Secure function exists in app schema
(SELECT COUNT(*)
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'app'
AND p.proname = 'login_secure')::INT AS secure_function_exists,
-- Injection payload returns 'Login failed' from the secure function
(SELECT app.login_secure($$ ' OR '1'='1' -- $$, 'x'))
AS injection_blocked,
-- Legitimate login still works through the secure function
(SELECT app.login_secure('alice', 'hash_alice')) AS normal_login;
Expected results:
| vuln_function_exists | secure_function_exists | injection_blocked | normal_login |
|---|---|---|---|
| 1 | 1 | Login failed | alice |
Additional Requirement¶
Secure product search function (20 pts)
Create a third function app.search_products_secure(p_name TEXT) that:
- Searches
app.productsby name using a parameterized query (no string concatenation). - Returns only the public columns:
id,name,priceโ neverinternal_cost. - Uses
ILIKEorLIKEfor partial-match searching (e.g., searching for'Widget'returns all Widget products).
CREATE OR REPLACE FUNCTION app.search_products_secure(p_name TEXT)
RETURNS TABLE(id INT, name TEXT, price NUMERIC) AS $$
BEGIN
RETURN QUERY
SELECT p.id, p.name, p.price
FROM app.products p
WHERE p.name ILIKE '%' || p_name || '%';
END;
$$ LANGUAGE plpgsql;
Then demonstrate both of these:
-- 1. Injection attempt returns no rows (cannot reach internal_cost)
SELECT * FROM app.search_products_secure(
$$ ' UNION SELECT internal_cost::TEXT, NULL, NULL FROM app.products-- $$
);
-- 2. Legitimate search returns expected public data
SELECT * FROM app.search_products_secure('Widget');
Submit: Both queries and screenshots of their outputs. The injection attempt must return 0 rows; the legitimate search must return the Widget A and Widget B rows with no internal_cost column visible.
Reflection Questions¶
Answer each question in 3โ5 sentences in your lab report.
-
Why Parameterization Prevents Injection โ Explain in technical terms why string concatenation causes SQL injection while parameterized queries do not. What specifically does PostgreSQL do differently at the parse and plan stage when it encounters a parameter placeholder (
$1) versus a concatenated string? At what point in query processing is the distinction made? -
Blast Radius of a Single Vulnerability โ The UNION injection extracted
internal_costfrom a completely different table (app.products) through a vulnerability in the login function. What does this tell you about the blast radius of a single SQL injection vulnerability? Beyond the table the vulnerable query targets, what else is potentially accessible to an attacker with UNION injection capability? -
Second-Order Injection and Testing โ Second-order SQL injection stores malicious input safely but triggers it through a later vulnerable query. Why is this significantly harder to detect than direct (first-order) injection? What specific testing approach โ beyond standard input fuzzing โ is required to reliably find second-order injection vulnerabilities in a large application?
Grading Rubric
| Component | Points |
|---|---|
| Lab steps completed with screenshots at all checkpoints | 50 |
| Verification script โ all 4 values correct | 30 |
Additional requirement โ search_products_secure implemented, both demo queries shown | 20 |
| Total | 100 |